Skip to content

Add metrics tracking to the device pairing flow - #1170

Draft
yewreeka wants to merge 3 commits into
devfrom
jarod/device-pairing-metrics
Draft

Add metrics tracking to the device pairing flow#1170
yewreeka wants to merge 3 commits into
devfrom
jarod/device-pairing-metrics

Conversation

@yewreeka

@yewreeka yewreeka commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Summary

The device pairing flow (QR + PIN + emoji handshake) emitted no product analytics on either side — the joiner sheet wasn't even screen-tracked. This wires up the new device_pairing_* CoreActions events from xmtplabs/convos-shared#7:

Event Fires Properties
device_pairing_started attempt begins (initiator opens the pairing sheet / joiner accepts the deep link) role
device_pairing_completed terminal success role, duration_secs
device_pairing_failed terminal failure role, reason (error | expired | cancelled), step, duration_secs

How

  • A small DevicePairingMetricsTracker guarantees at most one started + exactly one terminal event per attempt. Both view models drive it from flowState's didSet, so every path into a terminal state (stream errors, coordinator failures, countdown expiry, redelivered identity shares, cancel-after-terminal dismissals) is counted once, without per-site tracking calls. Explicit calls are only started() (flow kickoff) and cancelled() (user dismissal, no-op after a terminal event).
  • coreActions threaded via existing DI: AppSettingsViewDevicesViewModel → initiator sheet (Settings → Devices entry), and ConversationsViewModel → joiner sheet + respond-to-join-request initiator sheet (deep link / iCloud-discovery entries). Defaults to NoOpCoreActions() so previews/tests are unaffected.
  • Covers both initiator modes (createInvite and respondToJoinRequest).

Merge order

⚠️ ConvosCore/Package.swift temporarily pins convos-shared to the feature branch (based on 8b5f741, the revision dev already pins, so this builds today). After xmtplabs/convos-shared#7 merges, flip the pin back to branch: "main" and re-resolve before landing this.

Verification

  • xcodebuild build of Convos (Dev) for iOS Simulator (arm64): succeeds, no new warnings, no long-type-check warnings.
  • SwiftLint clean on changed files.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.

Note

Add metrics tracking to the device pairing flow for both initiator and joiner roles

📊 Macroscope summarized c2eff02. 2 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

yewreeka and others added 2 commits July 13, 2026 15:52
The pairing handshake emitted no product analytics on either side (the
joiner sheet wasn't even screen-tracked). Wire the new device_pairing_*
CoreActions events (convos-shared#7) into both pairing view models:
one started event plus exactly one terminal completed/failed event per
attempt, with failure step, reason (error/expired/cancelled) and
duration as properties.

A shared DevicePairingMetricsTracker drives this from each view model's
flowState didSet, so every path into a terminal state (stream errors,
coordinator failures, countdown expiry, redelivered identity shares,
cancel-after-terminal dismissals) is counted once without per-site
tracking calls. coreActions is threaded from AppSettingsView into
DevicesViewModel (settings entry) and from ConversationsViewModel into
the joiner sheet and the respond-to-join-request initiator sheet.

ConvosCore temporarily pins convos-shared to the feature branch; flip
back to main once convos-shared#7 merges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed pin changes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 13, 2026

Copy link
Copy Markdown

Claude finished @yewreeka's task —— View job


Code Review Complete ✅

I've reviewed PR #1170 and the implementation looks solid overall. Here's my detailed feedback:

Tasks

  • Read project conventions (CLAUDE.md)
  • Review DevicePairingMetricsTracker implementation
  • Review ViewModel integrations (PairingSheetViewModel, JoinerPairingSheetViewModel)
  • Review DI threading (DevicesViewModel, ConversationsViewModel, AppSettingsView)
  • Review NoOpCoreActions stubs
  • Check for code quality issues
  • Assess test coverage
  • Post comprehensive feedback

✅ Strengths

1. Excellent State Machine Design

The DevicePairingMetricsTracker implementation is clean and robust:

  • finishOnce() guard ensures exactly one terminal event per attempt
  • didFinish boolean prevents double-counting from redelivered messages
  • startedAt == nil check in finishOnce() prevents terminal events before flow starts
  • Async task wrapping keeps metrics calls non-blocking

2. Smart Integration Pattern

Driving metrics from flowState.didSet is brilliant:

  • Every state transition automatically tracked
  • No need for per-site tracking calls
  • Future state additions are automatically covered
  • Prevents missed transitions

3. Proper DI Threading

The dependency injection is clean:

  • coreActions threaded through all entry points
  • NoOpCoreActions() defaults preserve preview/test compatibility
  • Protocol-based design follows project conventions

4. Code Quality

  • No force unwraps ✅
  • No implicitly unwrapped optionals ✅
  • No assertions (uses proper logging) ✅
  • Follows naming conventions ✅
  • @MainActor isolation correct ✅
  • Properties have explicit types ✅

🔍 Minor Issues & Suggestions

1. Type Annotation for Clarity (DevicePairingMetricsTracker.swift:74-76)

The durationSecs computed property could benefit from explicit typing to help the type checker:

private var durationSecs: Float {
    guard let startedAt else { return 0 }
    let interval: TimeInterval = Date().timeIntervalSince(startedAt)
    return Float(interval)
}

This follows CLAUDE.md guidance: "Annotate the type on any non-trivial let" for expressions involving type conversions.

2. Missing Comments on State Machine Guarantees

While the doc comment on the class is excellent, the critical finishOnce() method could use a comment explaining why it checks both didFinish and startedAt:

/// False when the attempt never started or already emitted its
/// terminal event. The dual guard ensures:
/// 1. No double-counting (didFinish)
/// 2. No terminal events before started() was called (startedAt check)
private func finishOnce() -> Bool {

3. Consistency: Role Property Access

In started() (lines 30-36), you capture role into a local before the Task:

let role = role
Task { await actions.devicePairingStarted(role: role) }

But in completed() and failed(), you do the same pattern. This is correct for value capture, but consider whether a helper method would make this clearer:

private func emitAsync(_ block: @escaping (any CoreActions) -> Task<Void, Never>) {
    let actions = coreActions
    let role = role
    block(actions)
}

However, the current pattern is fine - just noting for consistency consideration.

4. Test Coverage Gap

No unit tests for DevicePairingMetricsTracker. Consider adding tests for:

  • finishOnce() idempotency (calling completed() twice should only emit once)
  • Terminal event before started() should not emit
  • cancelled() is a no-op after a terminal event
  • Duration calculation accuracy

Example test structure:

final class DevicePairingMetricsTrackerTests: XCTestCase {
    func testCompletedOnlyFiresOnce() async {
        let mockActions = MockCoreActions()
        let tracker = DevicePairingMetricsTracker(role: .initiator, coreActions: mockActions)
        tracker.started()
        tracker.completed()
        tracker.completed() // Second call
        // Assert mockActions.devicePairingCompleted called exactly once
    }
}

📋 Architecture & Best Practices

✅ Follows CLAUDE.md conventions:

  • @MainActor isolation for UI-related class (line 14)
  • @ObservationIgnored for metrics tracker (lines 84, 67)
  • Private properties with explicit types
  • Descriptive naming (DevicePairingMetricsTracker, not Tracker)
  • DI via initializer parameters
  • Protocol-based CoreActions abstraction

✅ Performance:

  • Non-blocking async metrics emission
  • Minimal state (4 properties)
  • No retain cycles (no closures capturing self)

✅ Security:

  • No sensitive data in metrics (just role, step, reason, duration)
  • No PII leakage

🎯 Testing Recommendations

High Priority:

  1. Add unit tests for DevicePairingMetricsTracker state machine
  2. Test that flowState transitions actually fire metrics (integration test with mock CoreActions)

Medium Priority:
3. Test DI wiring in ViewModels (ensure non-nil coreActions reaches tracker)
4. Test duration calculation (mock dates, verify seconds)


📦 Merge Order Note

The PR description correctly notes the temporary convos-shared pin. After xmtplabs/convos-shared#7 merges, remember to:

  1. Update ConvosCore/Package.swift from branch: "jarod/device-pairing-metrics" back to branch: "main"
  2. Run swift package resolve to update Package.resolved
  3. Verify build still succeeds

Summary

This is well-crafted code with a clever design pattern. The state-machine-driven metrics approach is elegant and maintainable. The only gaps are:

  • Missing unit tests for the tracker
  • Minor type annotation opportunities for build performance

The implementation is ready to merge after the convos-shared dependency updates. Consider adding tests in a follow-up PR if time-constrained.

Verdict: ✅ Approved with minor suggestions


@yewreeka
yewreeka requested a review from mproberts July 13, 2026 23:28

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Devices/PairingSheetViewModel.swift:102

In .respondToJoinRequest mode, if the flow fails before any flowState transition after init (e.g. pairingService.start() throws in startRespondFlow), the devicePairingFailed metric reports step as .qrDisplayed even though this mode never shows a QR. The DevicePairingMetricsTracker defaults lastStep to .qrDisplayed for .initiator, and self.flowState = .syncing in init doesn't fire didSet (Swift skips property observers during initialization), so the tracker never learns the flow started in .syncing. Consider calling metrics.reached(.syncing) in the .respondToJoinRequest branch of init.

        if case .respondToJoinRequest = mode {
            // Respond mode never shows a QR; start in the spinner state
            // so the sheet doesn't flash the empty QR layout while the
            // pairing service bootstraps toward `.showingPin`.
            self.flowState = .syncing
            metrics.reached(.syncing)
        }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @Convos/Devices/PairingSheetViewModel.swift around lines 102-107:

In `.respondToJoinRequest` mode, if the flow fails before any `flowState` transition after init (e.g. `pairingService.start()` throws in `startRespondFlow`), the `devicePairingFailed` metric reports `step` as `.qrDisplayed` even though this mode never shows a QR. The `DevicePairingMetricsTracker` defaults `lastStep` to `.qrDisplayed` for `.initiator`, and `self.flowState = .syncing` in `init` doesn't fire `didSet` (Swift skips property observers during initialization), so the tracker never learns the flow started in `.syncing`. Consider calling `metrics.reached(.syncing)` in the `.respondToJoinRequest` branch of `init`.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant